--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 4accb00fecf4267fc16e0a4aaeaf7a6d8989dbd7
Parents : 5307f98
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T23:03:16-05:00
feat(relay): add relay chat functionality with unread badge support and improve message handling for relay links
Changes
26 files changed, 921 insertions(+), 40 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 4fe87dec..0c1c3366 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -17500,6 +17500,10 @@ class ReticulumMeshChat:
)
if "rrc_enabled" in data:
self.config.rrc_enabled.set(self._parse_bool(data["rrc_enabled"]))
+ if "rrc_unread_badges_enabled" in data:
+ self.config.rrc_unread_badges_enabled.set(
+ self._parse_bool(data["rrc_unread_badges_enabled"]),
+ )
if "message_outbound_bubble_color" in data:
self.config.message_outbound_bubble_color.set(
@@ -19369,6 +19373,7 @@ class ReticulumMeshChat:
"messages_multi_pane_enabled": ctx.config.messages_multi_pane_enabled.get(),
"nomad_tabs_enabled": ctx.config.nomad_tabs_enabled.get(),
"rrc_enabled": ctx.config.rrc_enabled.get(),
+ "rrc_unread_badges_enabled": ctx.config.rrc_unread_badges_enabled.get(),
"message_icon_size": ctx.config.message_icon_size.get(),
"ui_transparency": ctx.config.ui_transparency.get(),
"ui_glass_enabled": ctx.config.ui_glass_enabled.get(),
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index 0043e37c..2dc96232 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -580,6 +580,11 @@ class ConfigManager:
True,
)
self.rrc_enabled = self.BoolConfig(self, "rrc_enabled", True)
+ self.rrc_unread_badges_enabled = self.BoolConfig(
+ self,
+ "rrc_unread_badges_enabled",
+ True,
+ )
self.nomad_micron_wasm_enabled = self.BoolConfig(
self,
"nomad_micron_wasm_enabled",
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index c9c2703f..aa0fc840 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -608,6 +608,7 @@ import ToneGenerator from "../js/ToneGenerator";
import { listNavItems } from "../js/registries/navRegistry.js";
import { onWsEvent, offWsEvent } from "../js/registries/wsEventRegistry.js";
import { handleLxmIngestUriResult } from "../js/ingestUriResultNavigation.js";
+import { applyRelayShareLink, parseMeshchatRelayUri } from "../js/relayLinkUtils.js";
import logoUrl from "../assets/images/logo.png";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../js/browserLayoutStore";
@@ -2083,6 +2084,10 @@ export default {
);
return;
}
+ if (/^(meshchatx|meshchat):\/\/relay\b/i.test(normalizedUrl)) {
+ this.openRelayShareLink(normalizedUrl);
+ return;
+ }
if (/^lxm(a|f)?:\/\//i.test(normalizedUrl)) {
WebSocketConnection.send(
JSON.stringify({
@@ -2108,6 +2113,30 @@ export default {
console.error("Failed to handle protocol link:", e);
}
},
+ async openRelayShareLink(uri) {
+ const parsed = parseMeshchatRelayUri(uri);
+ if (!parsed) {
+ ToastUtils.error(this.$t("messages.relay_link_invalid"));
+ return;
+ }
+ if (GlobalState.config?.rrc_enabled === false) {
+ ToastUtils.warning(this.$t("messages.relay_link_disabled"));
+ return;
+ }
+ try {
+ const result = await applyRelayShareLink(parsed);
+ await this.$router.push({
+ name: "relay-chat",
+ query: {
+ hub: result.hub_hash,
+ ...(result.room ? { room: result.room } : {}),
+ },
+ });
+ ToastUtils.success(this.$t("messages.relay_link_opened"));
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("messages.relay_link_failed"));
+ }
+ },
handleKeyboardShortcut(action) {
switch (action) {
case "nav_messages":
diff --git a/meshchatx/src/frontend/components/map/MapBrowser.vue b/meshchatx/src/frontend/components/map/MapBrowser.vue
index 7fcc46bf..e4289e57 100644
--- a/meshchatx/src/frontend/components/map/MapBrowser.vue
+++ b/meshchatx/src/frontend/components/map/MapBrowser.vue
@@ -44,11 +44,11 @@
{{ tabTitle(tab) }}
</span>
<span
- class="shrink-0 rounded p-0.5 text-sem-fg-muted hover:bg-sem-surface hover:text-sem-fg"
+ class="shrink-0 rounded p-0.5 text-sem-fg-muted opacity-0 transition-opacity hover:bg-sem-surface hover:text-sem-fg group-hover:opacity-100 group-focus-within:opacity-100"
:title="$t('common.cancel')"
@click.stop="closeTab(tab.id)"
>
- <MaterialDesignIcon icon-name="close" class="size-3.5" />
+ <MaterialDesignIcon icon-name="close" class="size-4" />
</span>
</button>
<button
diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
index dafcf576..351bf756 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
@@ -646,6 +646,7 @@
chatItem.lxmf_message.content &&
!cv.getParsedItems(chatItem)?.isOnlyPaperMessage &&
!cv.getParsedItems(chatItem)?.isOnlyMapLink &&
+ !cv.getParsedItems(chatItem)?.isOnlyRelayLink &&
!cv.shouldHideAutoImageCaption(chatItem) &&
cv.isMessageBodyTooLargeForDisplay(chatItem)
"
@@ -679,6 +680,7 @@
chatItem.lxmf_message.content &&
!cv.getParsedItems(chatItem)?.isOnlyPaperMessage &&
!cv.getParsedItems(chatItem)?.isOnlyMapLink &&
+ !cv.getParsedItems(chatItem)?.isOnlyRelayLink &&
!cv.shouldHideAutoImageCaption(chatItem)
"
class="min-w-0"
@@ -964,6 +966,49 @@
{{ $t("messages.map_link_copy_uri") }}
</button>
</div>
+
+ <div
+ v-if="cv.getParsedItems(chatItem).relayLink"
+ class="flex flex-col gap-2 p-3 rounded-xl bg-violet-50 dark:bg-violet-950/30 border border-violet-200 dark:border-violet-800/50"
+ >
+ <div class="flex items-center gap-2 text-violet-800 dark:text-violet-300">
+ <MaterialDesignIcon icon-name="forum-outline" class="size-5" />
+ <span class="text-sm font-bold">{{
+ cv.getParsedItems(chatItem).relayLink.parsed.room
+ ? $t("messages.relay_link_room_title")
+ : $t("messages.relay_link_hub_title")
+ }}</span>
+ </div>
+ <div
+ v-if="cv.getParsedItems(chatItem).relayLink.parsed.name"
+ class="text-xs font-semibold text-violet-900/90 dark:text-violet-200/90 truncate"
+ >
+ {{ cv.getParsedItems(chatItem).relayLink.parsed.name }}
+ </div>
+ <div
+ v-if="cv.getParsedItems(chatItem).relayLink.parsed.room"
+ class="text-xs text-violet-900/80 dark:text-violet-200/90"
+ >
+ #{{ cv.getParsedItems(chatItem).relayLink.parsed.room }}
+ </div>
+ <div class="text-[10px] font-mono text-violet-900/80 dark:text-violet-200/90 break-all">
+ {{ cv.getParsedItems(chatItem).relayLink.parsed.hub }}
+ </div>
+ <button
+ type="button"
+ class="w-full py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg text-xs font-bold transition-colors shadow-xs"
+ @click="cv.openRelayShareFromParsed(cv.getParsedItems(chatItem).relayLink.parsed)"
+ >
+ {{ $t("messages.relay_link_join") }}
+ </button>
+ <button
+ type="button"
+ class="w-full py-2 bg-white dark:bg-zinc-900 border border-violet-200 dark:border-violet-800 text-violet-800 dark:text-violet-200 rounded-lg text-xs font-bold"
+ @click="cv.copyRelayShareUri(cv.getParsedItems(chatItem).relayLink.uri)"
+ >
+ {{ $t("messages.relay_link_copy_uri") }}
+ </button>
+ </div>
</div>
<!-- audio field -->
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 95331dbb..a4d37e1f 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1816,6 +1816,7 @@ import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import { findMapUriInContent, mapLinkKindFromMessage, parseMeshchatMapUri } from "../../js/mapLinkUtils.js";
+import { applyRelayShareLink, findRelayUriInContent, parseMeshchatRelayUri } from "../../js/relayLinkUtils.js";
import { LXMF_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions";
import { createOutboundQueue } from "../../js/outboundSendQueue";
import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url";
@@ -2655,6 +2656,9 @@ export default {
if (this.getParsedItems(chatItem)?.isOnlyMapLink) {
return false;
}
+ if (this.getParsedItems(chatItem)?.isOnlyRelayLink) {
+ return false;
+ }
if (this.shouldHideAutoImageCaption(chatItem)) {
return false;
}
@@ -3394,6 +3398,22 @@ export default {
}
}
+ const relayUri = findRelayUriInContent(content);
+ if (relayUri && !items.paperMessage && !items.mapLink) {
+ const parsed = parseMeshchatRelayUri(relayUri);
+ if (parsed) {
+ let t = content.trim().replace(relayUri, "").trim();
+ t = t
+ .replace(/^MeshChatX\s+relay\s+room:\s*/i, "")
+ .replace(/^MeshChatX\s+relay:\s*/i, "")
+ .trim();
+ items.relayLink = { uri: relayUri, parsed };
+ if (t === "") {
+ items.isOnlyRelayLink = true;
+ }
+ }
+ }
+
return items;
},
async addContact(name, hash, lxmf_address = null, lxst_address = null) {
@@ -6134,6 +6154,39 @@ export default {
ToastUtils.error(this.$t("messages.clipboard_write_unavailable"));
}
},
+ async openRelayShareFromParsed(parsed) {
+ if (!parsed) {
+ return;
+ }
+ if (GlobalState.config?.rrc_enabled === false) {
+ ToastUtils.warning(this.$t("messages.relay_link_disabled"));
+ return;
+ }
+ try {
+ const result = await applyRelayShareLink(parsed);
+ await this.$router.push({
+ name: "relay-chat",
+ query: {
+ hub: result.hub_hash,
+ ...(result.room ? { room: result.room } : {}),
+ },
+ });
+ ToastUtils.success(this.$t("messages.relay_link_opened"));
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("messages.relay_link_failed"));
+ }
+ },
+ async copyRelayShareUri(uri) {
+ if (!uri) {
+ return;
+ }
+ const ok = await copyTextToClipboard(uri);
+ if (ok) {
+ ToastUtils.success(this.$t("messages.relay_link_copied"));
+ } else {
+ ToastUtils.error(this.$t("messages.clipboard_write_unavailable"));
+ }
+ },
isTelemetryOnly(msg) {
return isTelemetryOnlyMessage(msg);
},
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 25f1a9fd..69c63ade 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -29,6 +29,7 @@
:is-loading-more="isLoadingMore"
:has-more-conversations="hasMoreConversations"
:is-loading-more-announces="isLoadingMoreAnnounces"
+ :is-searching-announces="isSearchingAnnounces"
:has-more-announces="hasMoreAnnounces"
:peers-search-term="peersSearchTerm"
:total-peers-count="totalPeersCount"
@@ -398,6 +399,7 @@ export default {
hasMoreAnnounces: true,
isLoadingMoreAnnounces: false,
+ isSearchingAnnounces: false,
totalPeersCount: 0,
peersSearchTerm: "",
lxmfDeliveryAnnounces: [],
@@ -702,6 +704,9 @@ export default {
if (!append) {
this.announcesLoaded = true;
}
+ // capture the controller that belongs to *this* call so a later,
+ // superseding call can't have its own finally block clear the
+ // loading state for an in-flight search out from under it.
let myController = this.announcesAbortController;
try {
if (!append) {
@@ -710,6 +715,7 @@ export default {
}
this.announcesAbortController = new AbortController();
myController = this.announcesAbortController;
+ this.isSearchingAnnounces = true;
} else if (!this.announcesAbortController) {
this.announcesAbortController = new AbortController();
myController = this.announcesAbortController;
@@ -743,6 +749,9 @@ export default {
} finally {
if (this.announcesAbortController === myController) {
this.isLoadingMoreAnnounces = false;
+ if (!append) {
+ this.isSearchingAnnounces = false;
+ }
}
}
},
@@ -1585,6 +1594,7 @@ export default {
},
onPeersSearchChanged(term) {
this.peersSearchTerm = term;
+ this.isSearchingAnnounces = true;
if (this.peersRefreshTimeout) {
clearTimeout(this.peersRefreshTimeout);
}
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index dba8c6dd..6c15c8b1 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -678,13 +678,23 @@
>
<!-- search -->
<div class="p-1 border-b border-gray-200 dark:border-zinc-800">
- <input
- :value="peersSearchTerm"
- type="text"
- :placeholder="$t('messages.search_placeholder_announces', { count: totalPeersCount })"
- class="input-field"
- @input="onPeersSearchInput"
- />
+ <div class="relative">
+ <input
+ :value="peersSearchTerm"
+ type="text"
+ :placeholder="$t('messages.search_placeholder_announces', { count: totalPeersCount })"
+ class="input-field w-full"
+ :class="{ 'pr-7': isSearchingAnnounces }"
+ @input="onPeersSearchInput"
+ />
+ <span
+ v-if="isSearchingAnnounces"
+ class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400"
+ :title="$t('messages.searching_announces')"
+ >
+ <MaterialDesignIcon icon-name="loading" class="size-4 animate-spin" />
+ </span>
+ </div>
</div>
<!-- peers -->
@@ -774,21 +784,19 @@
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
</div>
</div>
- <div v-else class="mx-auto my-auto text-center leading-5">
- <!-- no peers at all -->
- <div v-if="peersCount === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
+ <div
+ v-else-if="isSearchingAnnounces && peersSearchTerm.trim() !== ''"
+ class="mx-auto my-auto text-center leading-5"
+ >
+ <div class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1 text-gray-500">
- <MaterialDesignIcon icon-name="account-search-outline" class="size-6" />
+ <MaterialDesignIcon icon-name="loading" class="size-6 animate-spin" />
</div>
- <div class="font-semibold">{{ $t("messages.no_peers_discovered") }}</div>
- <div>{{ $t("messages.waiting_for_announce") }}</div>
+ <div class="font-semibold">{{ $t("messages.searching_announces") }}</div>
</div>
-
- <!-- is searching, but no results -->
- <div
- v-if="peersSearchTerm !== '' && peersCount > 0"
- class="flex flex-col text-gray-900 dark:text-gray-100"
- >
+ </div>
+ <div v-else-if="peersSearchTerm.trim() !== ''" class="mx-auto my-auto text-center leading-5">
+ <div class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1 text-gray-500">
<MaterialDesignIcon icon-name="account-off-outline" class="size-6" />
</div>
@@ -796,6 +804,15 @@
<div>{{ $t("messages.no_search_results_peers") }}</div>
</div>
</div>
+ <div v-else class="mx-auto my-auto text-center leading-5">
+ <div class="flex flex-col text-gray-900 dark:text-gray-100">
+ <div class="mx-auto mb-1 text-gray-500">
+ <MaterialDesignIcon icon-name="account-search-outline" class="size-6" />
+ </div>
+ <div class="font-semibold">{{ $t("messages.no_peers_discovered") }}</div>
+ <div>{{ $t("messages.waiting_for_announce") }}</div>
+ </div>
+ </div>
</div>
</div>
</template>
@@ -880,6 +897,10 @@ export default {
type: Boolean,
default: false,
},
+ isSearchingAnnounces: {
+ type: Boolean,
+ default: false,
+ },
hasMoreAnnounces: {
type: Boolean,
default: false,
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
index 28536ba9..984f9fdd 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
@@ -30,12 +30,12 @@
>
<span class="min-w-0 flex-1 truncate text-left">{{ tabTitle(tab) }}</span>
<span
- class="shrink-0 rounded p-0.5 text-sem-fg-muted hover:bg-sem-surface hover:text-sem-fg"
+ class="shrink-0 rounded p-0.5 text-sem-fg-muted opacity-0 transition-opacity hover:bg-sem-surface hover:text-sem-fg group-hover:opacity-100 group-focus-within:opacity-100"
:title="$t('common.cancel')"
draggable="false"
@click.stop="closeTab(tab.id)"
>
- <MaterialDesignIcon icon-name="close" class="size-3.5" />
+ <MaterialDesignIcon icon-name="close" class="size-4" />
</span>
</button>
<button
diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index c7f8d3c8..ace64525 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -96,7 +96,7 @@
:class="statusIconColor(hub.status)"
/>
<span
- v-if="hubTotalUnread(hub) > 0"
+ v-if="showUnreadBadges && hubTotalUnread(hub) > 0"
class="absolute -top-0.5 -right-0.5 min-w-[14px] rounded-full bg-red-500 px-0.5 text-[9px] font-bold leading-tight text-white"
>
{{ formatUnreadBadge(hubTotalUnread(hub)) }}
@@ -154,7 +154,7 @@
</div>
</div>
<span
- v-if="hubTotalUnread(hub) > 0"
+ v-if="showUnreadBadges && hubTotalUnread(hub) > 0"
class="shrink-0 min-w-[1.25rem] rounded-full bg-red-500 px-1.5 py-0.5 text-center text-xs font-bold text-white"
>
{{ formatUnreadBadge(hubTotalUnread(hub)) }}
@@ -165,6 +165,15 @@
v-show="isExpanded(hub.hub_hash)"
class="border-t border-sem-border/50 px-2 py-2 space-y-2"
>
+ <button
+ type="button"
+ class="flex w-full items-center gap-1.5 px-1 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
+ :title="$t('relay_chat.copy_hash')"
+ @click.stop="copyHash(hub.hub_hash)"
+ >
+ <MaterialDesignIcon icon-name="content-copy" class="size-3.5 shrink-0" />
+ <span class="truncate">{{ formatHash(hub.hub_hash) }}</span>
+ </button>
<div class="flex items-center gap-1.5">
<button
v-if="!hub.connected"
@@ -229,7 +238,7 @@
<span class="truncate">{{ roomName }}</span>
</span>
<span
- v-if="roomUnreadCount(hub, roomName) > 0"
+ v-if="showUnreadBadges && roomUnreadCount(hub, roomName) > 0"
class="shrink-0 min-w-[1.125rem] rounded-full bg-red-500 px-1 text-center text-[10px] font-bold leading-4 text-white"
>
{{ formatUnreadBadge(roomUnreadCount(hub, roomName)) }}
@@ -780,6 +789,33 @@
</button>
</div>
<div class="flex shrink-0 items-center gap-1.5">
+ <button
+ v-if="!isHubAdded(hub.dest_hash)"
+ type="button"
+ :class="btnIcon"
+ :title="$t('relay_chat.host_join_as_client')"
+ :disabled="!hub.running || !hub.dest_hash"
+ @click="joinHostedAsClient(hub)"
+ >
+ <MaterialDesignIcon icon-name="login" class="size-4" />
+ </button>
+ <button
+ v-else
+ type="button"
+ :class="btnIcon"
+ :title="$t('relay_chat.host_leave_as_client')"
+ @click="leaveHostedAsClient(hub)"
+ >
+ <MaterialDesignIcon icon-name="logout" class="size-4" />
+ </button>
+ <button
+ type="button"
+ :class="btnIcon"
+ :title="$t('relay_chat.share_hub')"
+ @click="shareHubLink({ hub_hash: hub.dest_hash, name: hub.name })"
+ >
+ <MaterialDesignIcon icon-name="share-variant" class="size-4" />
+ </button>
<button
v-if="!hub.running"
type="button"
@@ -1046,16 +1082,32 @@
class="input-field"
/>
</div>
- <div class="space-y-1.5">
- <label class="block text-sm font-semibold text-sem-fg-secondary">{{
- $t("relay_chat.dest_name")
- }}</label>
- <input
- v-model="addHubForm.dest_name"
- type="text"
- placeholder="rrc.hub"
- class="input-field font-mono"
- />
+ <div class="rounded-lg border border-sem-border/70">
+ <button
+ type="button"
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm font-medium text-sem-fg-secondary transition-colors hover:bg-sem-surface/40"
+ @click="addHubAdvancedOpen = !addHubAdvancedOpen"
+ >
+ <MaterialDesignIcon
+ :icon-name="addHubAdvancedOpen ? 'chevron-down' : 'chevron-right'"
+ class="size-4 shrink-0"
+ />
+ {{ $t("relay_chat.advanced") }}
+ </button>
+ <div
+ v-show="addHubAdvancedOpen"
+ class="space-y-1.5 border-t border-sem-border/70 px-3 py-3"
+ >
+ <label class="block text-sm font-semibold text-sem-fg-secondary">{{
+ $t("relay_chat.dest_name")
+ }}</label>
+ <input
+ v-model="addHubForm.dest_name"
+ type="text"
+ placeholder="rrc.hub"
+ class="input-field font-mono"
+ />
+ </div>
</div>
<div class="flex justify-end gap-2 pt-1">
<button type="button" :class="btnSecondary" @click="showAddHub = false">
@@ -1198,6 +1250,12 @@
>
{{ $t("relay_chat.ctx_disconnect_hub") }}
</ContextMenuItem>
+ <ContextMenuItem @click="copyHubAddressFromMenu">
+ {{ $t("relay_chat.ctx_copy_hub_address") }}
+ </ContextMenuItem>
+ <ContextMenuItem @click="shareHubFromMenu">
+ {{ sidebarMenu.room ? $t("relay_chat.ctx_share_room") : $t("relay_chat.ctx_share_hub") }}
+ </ContextMenuItem>
<ContextMenuItem @click="openSettingsFromMenu">
{{ $t("relay_chat.ctx_hub_settings") }}
</ContextMenuItem>
@@ -1266,6 +1324,7 @@ import { MIN_VIRTUAL_RELAY_ENTRIES } from "./relayMessageListVirtual.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
+import { buildRelayShareMessage } from "../../js/relayLinkUtils.js";
import {
ANNOUNCE_SLIDER_POS_MAX,
announceMinutesToSliderPos,
@@ -1420,6 +1479,7 @@ export default {
sending: false,
joinRoomName: "",
showAddHub: false,
+ addHubAdvancedOpen: false,
addHubForm: {
hub_hash: "",
name: "",
@@ -1449,6 +1509,9 @@ export default {
rrcEnabled() {
return GlobalState.config?.rrc_enabled !== false;
},
+ showUnreadBadges() {
+ return GlobalState.config?.rrc_unread_badges_enabled !== false;
+ },
isPopoutMode() {
return Boolean(this.$route?.meta?.isPopout);
},
@@ -2047,6 +2110,27 @@ export default {
this.openSettings(hub);
}
},
+ copyHubAddressFromMenu() {
+ const hub = this.sidebarMenu.hub;
+ this.closeSidebarMenu();
+ if (hub?.hub_hash) {
+ this.copyHash(hub.hub_hash);
+ }
+ },
+ shareHubFromMenu() {
+ const hub = this.sidebarMenu.hub;
+ const room = this.sidebarMenu.room;
+ this.closeSidebarMenu();
+ if (!hub) {
+ return;
+ }
+ this.shareHubLink({
+ hub_hash: hub.hub_hash,
+ name: this.hubDisplayName(hub),
+ room: room || "",
+ aspect: hub.dest_name || "",
+ });
+ },
async leaveRoomFromMenu() {
const hub = this.sidebarMenu.hub;
const room = this.sidebarMenu.room;
@@ -2580,6 +2664,7 @@ export default {
},
openAddHub() {
this.addHubForm = { hub_hash: "", name: "", dest_name: "" };
+ this.addHubAdvancedOpen = false;
this.showAddHub = true;
},
async addHub() {
@@ -2899,6 +2984,60 @@ export default {
// clipboard may be unavailable
}
},
+ async shareHubLink({ hub_hash, name = "", room = "", aspect = "" } = {}) {
+ const text = buildRelayShareMessage({
+ hub: hub_hash,
+ name,
+ room,
+ aspect,
+ });
+ if (!text) {
+ ToastUtils.error(this.$t("relay_chat.action_failed"));
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(text);
+ ToastUtils.success(this.$t("relay_chat.share_copied"));
+ } catch {
+ ToastUtils.error(this.$t("relay_chat.action_failed"));
+ }
+ },
+ async joinHostedAsClient(hub) {
+ if (!hub?.dest_hash) {
+ return;
+ }
+ try {
+ const response = await window.api.post("/api/v1/rrc/hubs", {
+ hub_hash: hub.dest_hash,
+ name: hub.name || undefined,
+ dest_name: "rrc.hub",
+ connect: true,
+ });
+ ToastUtils.success(this.$t("relay_chat.host_joined_as_client"));
+ await this.fetchHubs();
+ const added = response.data?.hub;
+ if (added) {
+ this.selectedHubHash = added.hub_hash;
+ this.expandedHubs[added.hub_hash] = true;
+ } else {
+ this.selectedHubHash = hub.dest_hash;
+ this.expandedHubs[hub.dest_hash] = true;
+ }
+ this.view = "chat";
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ }
+ },
+ async leaveHostedAsClient(hub) {
+ if (!hub?.dest_hash) {
+ return;
+ }
+ const clientHub = this.hubs.find((h) => h.hub_hash === hub.dest_hash);
+ if (!clientHub) {
+ return;
+ }
+ await this.removeHub(clientHub);
+ },
onWebsocketMessage(message) {
let json;
try {
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index a58c3762..88272fc5 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -1392,6 +1392,22 @@
</span>
</label>
+ <label v-if="config.rrc_enabled" class="setting-toggle">
+ <Toggle
+ id="rrc-unread-badges"
+ v-model="config.rrc_unread_badges_enabled"
+ @update:model-value="onRrcUnreadBadgesEnabledChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("app.rrc_unread_badges_enabled")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.rrc_unread_badges_enabled_description")
+ }}</span>
+ </span>
+ </label>
+
<div class="pt-1">
<button
type="button"
@@ -3361,6 +3377,7 @@ export default {
messages_multi_pane_enabled: true,
nomad_tabs_enabled: true,
rrc_enabled: true,
+ rrc_unread_badges_enabled: true,
message_icon_size: 28,
ui_transparency: 0,
ui_glass_enabled: true,
@@ -4275,6 +4292,14 @@ export default {
"rrc_enabled"
);
},
+ async onRrcUnreadBadgesEnabledChange() {
+ await this.updateConfig(
+ {
+ rrc_unread_badges_enabled: this.config.rrc_unread_badges_enabled,
+ },
+ "rrc_unread_badges_enabled"
+ );
+ },
async resetAppearanceDefaults() {
this.config.theme = "light";
this.config.messages_sidebar_position = "left";
diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js
index 2c597ab1..063e5726 100644
--- a/meshchatx/src/frontend/js/GlobalState.js
+++ b/meshchatx/src/frontend/js/GlobalState.js
@@ -40,6 +40,7 @@ const globalState = reactive({
messages_multi_pane_enabled: true,
nomad_tabs_enabled: true,
rrc_enabled: true,
+ rrc_unread_badges_enabled: true,
},
});
diff --git a/meshchatx/src/frontend/js/relayLinkUtils.js b/meshchatx/src/frontend/js/relayLinkUtils.js
new file mode 100644
index 00000000..b80e1a7a
--- /dev/null
+++ b/meshchatx/src/frontend/js/relayLinkUtils.js
@@ -0,0 +1,119 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Offline-friendly relay chat deep links:
+ * meshchatx://relay?hub=&room=&name=&aspect=
+ * (meshchat://relay is accepted as an alias.)
+ */
+
+const RELAY_URI_IN_TEXT_RE = /(?:meshchatx|meshchat):\/\/relay\?[^\s<>]*/gi;
+const HUB_HASH_RE = /^[a-fA-F0-9]{32}$/;
+
+export function findRelayUriInContent(text) {
+ if (!text || typeof text !== "string") {
+ return null;
+ }
+ const matches = text.match(RELAY_URI_IN_TEXT_RE);
+ return matches && matches.length ? matches[0] : null;
+}
+
+export function parseMeshchatRelayUri(uri) {
+ if (!uri || typeof uri !== "string") {
+ return null;
+ }
+ const s = uri.trim();
+ if (!/^(meshchatx|meshchat):\/\/relay\b/i.test(s)) {
+ return null;
+ }
+ try {
+ const u = new URL(s);
+ const hub = String(u.searchParams.get("hub") || "")
+ .trim()
+ .toLowerCase();
+ if (!HUB_HASH_RE.test(hub)) {
+ return null;
+ }
+ const room = String(u.searchParams.get("room") || "").trim();
+ const name = String(u.searchParams.get("name") || "").trim();
+ const aspect = String(u.searchParams.get("aspect") || u.searchParams.get("dest_name") || "")
+ .trim()
+ .slice(0, 64);
+ return {
+ hub,
+ room: room || "",
+ name: name || "",
+ aspect: aspect || "rrc.hub",
+ raw: s,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function buildMeshchatRelayUri({ hub, room = "", name = "", aspect = "" } = {}) {
+ const h = String(hub || "")
+ .trim()
+ .toLowerCase();
+ if (!HUB_HASH_RE.test(h)) {
+ return null;
+ }
+ const parts = [`hub=${encodeURIComponent(h)}`];
+ const r = String(room || "").trim();
+ if (r) {
+ parts.push(`room=${encodeURIComponent(r)}`);
+ }
+ const n = String(name || "").trim();
+ if (n) {
+ parts.push(`name=${encodeURIComponent(n)}`);
+ }
+ const a = String(aspect || "").trim();
+ if (a && a !== "rrc.hub") {
+ parts.push(`aspect=${encodeURIComponent(a)}`);
+ }
+ return `meshchatx://relay?${parts.join("&")}`;
+}
+
+export function buildRelayShareMessage({ hub, room = "", name = "", aspect = "" } = {}) {
+ const uri = buildMeshchatRelayUri({ hub, room, name, aspect });
+ if (!uri) {
+ return null;
+ }
+ if (room) {
+ return `MeshChatX relay room: ${uri}`;
+ }
+ return `MeshChatX relay: ${uri}`;
+}
+
+/**
+ * Add (or reuse) a client hub from a parsed relay URI and optionally join a room.
+ * Returns { hub_hash, room } on success.
+ */
+export async function applyRelayShareLink(parsed, { api = typeof window !== "undefined" ? window.api : null } = {}) {
+ if (!parsed?.hub || !api) {
+ throw new Error("invalid relay share");
+ }
+ const hubHash = parsed.hub;
+ let hubs = [];
+ try {
+ const list = await api.get("/api/v1/rrc/hubs");
+ hubs = list.data?.hubs || [];
+ } catch {
+ hubs = [];
+ }
+ const existing = hubs.find((h) => String(h.hub_hash || "").toLowerCase() === hubHash);
+ if (!existing) {
+ await api.post("/api/v1/rrc/hubs", {
+ hub_hash: hubHash,
+ name: parsed.name || undefined,
+ dest_name: parsed.aspect || "rrc.hub",
+ connect: true,
+ });
+ } else if (!existing.connected) {
+ await api.post(`/api/v1/rrc/hubs/${hubHash}/connect`);
+ }
+ const room = String(parsed.room || "").trim();
+ if (room) {
+ await api.post(`/api/v1/rrc/hubs/${hubHash}/rooms`, { room });
+ }
+ return { hub_hash: hubHash, room };
+}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index f21ffb5e..8dd828f0 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -114,6 +114,8 @@
"nomad_tabs_enabled_description": "Öffnet mehrere NomadNet-Seiten in Tabs auf größeren Bildschirmen.",
"rrc_enabled": "Relais-Chat",
"rrc_enabled_description": "Aktiviert Relais-Chat, Hub-Erkennung und Hosting. Wenn aus, ist Relais-Chat ausgeblendet und Hub-Ankündigungen werden ignoriert.",
+ "rrc_unread_badges_enabled": "Relay-Chat-Ungelesen-Badges",
+ "rrc_unread_badges_enabled_description": "Ungelesene Nachrichten-Zähler auf Hubs und Räumen anzeigen. Erwähnungen im Navigations-Badge bleiben unverändert.",
"reset_appearance_defaults": "Erscheinungsbild auf Standard zurücksetzen",
"light_theme": "Helles Thema",
"dark_theme": "Dunkles Thema",
@@ -1500,6 +1502,7 @@
"no_peers_discovered": "Keine Peers entdeckt",
"waiting_for_announce": "Warten auf Ankündigungen!",
"no_search_results_peers": "Ihre Suche ergab keine Treffer bei den Peers!",
+ "searching_announces": "Ankündigungen werden durchsucht...",
"direct": "Direkt",
"downloading": "Wird heruntergeladen",
"hops": "{count} Hops",
@@ -1639,6 +1642,15 @@
"failed_send_ingest": "Senden der Ingest-Anfrage fehlgeschlagen",
"address_copied": "Ihre LXMF-Adresse wurde in die Zwischenablage kopiert",
"map_link_share_title": "Geteilte Kartenansicht",
+ "relay_link_hub_title": "Geteilter Relay-Chat-Hub",
+ "relay_link_room_title": "Geteilter Relay-Chat-Raum",
+ "relay_link_join": "In Relay Chat beitreten",
+ "relay_link_copy_uri": "Relay-Link kopieren",
+ "relay_link_copied": "Relay-Link kopiert",
+ "relay_link_opened": "In Relay Chat geöffnet",
+ "relay_link_invalid": "Ungültiger Relay-Link",
+ "relay_link_failed": "Relay-Link konnte nicht geöffnet werden",
+ "relay_link_disabled": "Relay Chat ist in den Einstellungen deaktiviert",
"map_link_ping_title": "Karten-Ping",
"map_link_open": "Auf Karte öffnen",
"map_link_copy_uri": "MeshChatX-Link kopieren",
@@ -3290,6 +3302,7 @@
"hub_name": "Hub-Name (optional)",
"hub_name_placeholder": "Mein bevorzugter Hub",
"dest_name": "Ziel-Aspekt (optional)",
+ "advanced": "Erweitert",
"no_hubs": "Noch keine Hubs konfiguriert. Fügen Sie einen hinzu, um zu beginnen.",
"no_rooms": "Keine Räume beigetreten. Treten Sie einem Raum bei, um zu chatten.",
"available_rooms": "Verfügbare Räume",
@@ -3342,6 +3355,11 @@
"no_hosted_hubs": "Noch keine gehosteten Hubs. Erstelle einen, um Chat weiterzuleiten.",
"copy_hash": "Ziel-Hash kopieren",
"hash_copied": "Ziel-Hash kopiert",
+ "share_hub": "Hub-Link teilen",
+ "share_copied": "Relay-Freigabelink kopiert",
+ "host_join_as_client": "Diesem Hub als Client beitreten",
+ "host_leave_as_client": "Diesen Hub als Client verlassen",
+ "host_joined_as_client": "Gehostetem Hub als Client beigetreten",
"host_start": "Starten",
"host_status_running": "Läuft",
"host_status_stopped": "Gestoppt",
@@ -3435,6 +3453,9 @@
"ctx_connect_hub": "Verbinden",
"ctx_disconnect_hub": "Trennen",
"ctx_hub_settings": "Hub-Einstellungen",
+ "ctx_copy_hub_address": "Hub-Adresse kopieren",
+ "ctx_share_hub": "Hub teilen",
+ "ctx_share_room": "Raum teilen",
"ctx_remove_hub": "Hub entfernen",
"ctx_leave_room": "Raum verlassen",
"ctx_reply_quote": "Mit Zitat antworten",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index ad224d56..627d1f61 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -144,6 +144,8 @@
"nomad_tabs_enabled_description": "Open multiple NomadNet pages in tabs on larger screens.",
"rrc_enabled": "Relay Chat",
"rrc_enabled_description": "Enable relay chat, hub discovery, and hosting. When off, relay chat is hidden and hub announces are ignored.",
+ "rrc_unread_badges_enabled": "Relay Chat unread badges",
+ "rrc_unread_badges_enabled_description": "Show unread message counters on hubs and rooms. Mentions in the nav badge are unaffected.",
"reset_appearance_defaults": "Reset appearance to defaults",
"light_theme": "Light Theme",
"dark_theme": "Dark Theme",
@@ -1448,6 +1450,7 @@
"no_peers_discovered": "No Peers Discovered",
"waiting_for_announce": "Waiting for someone to announce!",
"no_search_results_peers": "Your search didn't match any Peers!",
+ "searching_announces": "Searching announces...",
"direct": "Direct",
"downloading": "Downloading",
"hops": "{count} hops",
@@ -1583,6 +1586,15 @@
"failed_send_ingest": "Failed to send ingest request",
"address_copied": "Your LXMF address copied to clipboard",
"map_link_share_title": "Shared map view",
+ "relay_link_hub_title": "Shared Relay Chat hub",
+ "relay_link_room_title": "Shared Relay Chat room",
+ "relay_link_join": "Join in Relay Chat",
+ "relay_link_copy_uri": "Copy relay link",
+ "relay_link_copied": "Relay link copied",
+ "relay_link_opened": "Opened in Relay Chat",
+ "relay_link_invalid": "Invalid relay link",
+ "relay_link_failed": "Could not open relay link",
+ "relay_link_disabled": "Relay Chat is disabled in settings",
"map_link_ping_title": "Map ping",
"map_link_open": "Open on map",
"map_link_copy_uri": "Copy meshchatx link",
@@ -2636,6 +2648,7 @@
"hub_name": "Hub Name (optional)",
"hub_name_placeholder": "My favourite hub",
"dest_name": "Destination Aspect (optional)",
+ "advanced": "Advanced",
"no_hubs": "No hubs configured yet. Add one to get started.",
"no_rooms": "No rooms joined. Join a room to start chatting.",
"available_rooms": "Available Rooms",
@@ -2688,6 +2701,11 @@
"no_hosted_hubs": "No hosted hubs yet. Create one to start relaying chat.",
"copy_hash": "Copy destination hash",
"hash_copied": "Destination hash copied",
+ "share_hub": "Share hub link",
+ "share_copied": "Relay share link copied",
+ "host_join_as_client": "Join this hub as a client",
+ "host_leave_as_client": "Leave this hub as a client",
+ "host_joined_as_client": "Joined hosted hub as client",
"host_start": "Start",
"host_status_running": "Running",
"host_status_stopped": "Stopped",
@@ -2756,6 +2774,9 @@
"ctx_connect_hub": "Connect",
"ctx_disconnect_hub": "Disconnect",
"ctx_hub_settings": "Hub settings",
+ "ctx_copy_hub_address": "Copy hub address",
+ "ctx_share_hub": "Share hub",
+ "ctx_share_room": "Share room",
"ctx_remove_hub": "Remove hub",
"ctx_leave_room": "Leave room",
"ctx_reply_quote": "Reply with quote",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index cb92de34..1a94b532 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -143,6 +143,8 @@
"nomad_tabs_enabled_description": "Abre varias páginas de NomadNet en pestañas en pantallas grandes.",
"rrc_enabled": "Chat de retransmision",
"rrc_enabled_description": "Activa el chat de retransmision, el descubrimiento de hubs y el alojamiento. Si esta desactivado, el chat de retransmision se oculta y se ignoran los anuncios de hubs.",
+ "rrc_unread_badges_enabled": "Insignias de no leídos de Relay Chat",
+ "rrc_unread_badges_enabled_description": "Mostrar contadores de mensajes no leídos en hubs y salas. Las menciones en la insignia de navegación no se ven afectadas.",
"reset_appearance_defaults": "Reiniciar la apariencia a predeterminados",
"light_theme": "Tema de luz",
"dark_theme": "Tema oscuro",
@@ -1448,6 +1450,7 @@
"no_peers_discovered": "No se descubrieron pares",
"waiting_for_announce": "¡Esperando a que alguien anuncie!",
"no_search_results_peers": "¡Tu búsqueda no coincidió con ningún Peers!",
+ "searching_announces": "Buscando anuncios...",
"direct": "Directo",
"downloading": "Descarga",
"hops": "Umbrales{count}",
@@ -1583,6 +1586,15 @@
"failed_send_ingest": "Error al enviar solicitud de ingesta",
"address_copied": "Tu dirección LXMF copiada para el portapapeles",
"map_link_share_title": "Vista de mapa compartida",
+ "relay_link_hub_title": "Hub de Relay Chat compartido",
+ "relay_link_room_title": "Sala de Relay Chat compartida",
+ "relay_link_join": "Unirse en Relay Chat",
+ "relay_link_copy_uri": "Copiar enlace de relay",
+ "relay_link_copied": "Enlace de relay copiado",
+ "relay_link_opened": "Abierto en Relay Chat",
+ "relay_link_invalid": "Enlace de relay no válido",
+ "relay_link_failed": "No se pudo abrir el enlace de relay",
+ "relay_link_disabled": "Relay Chat está desactivado en la configuración",
"map_link_ping_title": "Ping en el mapa",
"map_link_open": "Abrir en el mapa",
"map_link_copy_uri": "Copiar enlace de MeshChatX",
@@ -3290,6 +3302,7 @@
"hub_name": "Nombre del hub (opcional)",
"hub_name_placeholder": "Mi hub favorito",
"dest_name": "Aspecto de destino (opcional)",
+ "advanced": "Avanzado",
"no_hubs": "Aun no hay hubs configurados. Anade uno para empezar.",
"no_rooms": "No te has unido a ninguna sala. Unete a una para chatear.",
"available_rooms": "Salas disponibles",
@@ -3342,6 +3355,11 @@
"no_hosted_hubs": "Aún no hay hubs alojados. Crea uno para empezar a retransmitir el chat.",
"copy_hash": "Copiar hash de destino",
"hash_copied": "Hash de destino copiado",
+ "share_hub": "Compartir enlace del hub",
+ "share_copied": "Enlace de compartición de relay copiado",
+ "host_join_as_client": "Unirse a este hub como cliente",
+ "host_leave_as_client": "Salir de este hub como cliente",
+ "host_joined_as_client": "Unido al hub alojado como cliente",
"host_start": "Iniciar",
"host_status_running": "En ejecución",
"host_status_stopped": "Detenido",
@@ -3435,6 +3453,9 @@
"ctx_connect_hub": "Conectar",
"ctx_disconnect_hub": "Desconectar",
"ctx_hub_settings": "Ajustes del hub",
+ "ctx_copy_hub_address": "Copiar dirección del hub",
+ "ctx_share_hub": "Compartir hub",
+ "ctx_share_room": "Compartir sala",
"ctx_remove_hub": "Eliminar hub",
"ctx_leave_room": "Salir de la sala",
"ctx_reply_quote": "Responder con cita",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index b93fe6bf..6390785f 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -144,6 +144,8 @@
"nomad_tabs_enabled_description": "Avaa NomadNet-sivut välilehdillä suurilla näytöillä.",
"rrc_enabled": "Keskustelukanavat",
"rrc_enabled_description": "Kytke päälle keskustelukanavat, keskusten havaitseminen ja isännöinti. Pois kytkettynä keskustelukanavia tai keskusten kuulutuksia ei näytetä.",
+ "rrc_unread_badges_enabled": "Relay Chat -lukemattomat merkit",
+ "rrc_unread_badges_enabled_description": "Näytä lukemattomien viestien laskurit hubeissa ja huoneissa. Navigaation mainintamerkkiin ei vaikuta.",
"reset_appearance_defaults": "Palauta ulkoasun oletukset",
"light_theme": "Vaalea teema",
"dark_theme": "Tumma teema",
@@ -1448,6 +1450,7 @@
"no_peers_discovered": "Osallistujia ei löytynyt",
"waiting_for_announce": "Odotetaan kuulutuksia!",
"no_search_results_peers": "Yksikään osallistuja ei vastannut hakuasi!",
+ "searching_announces": "Haetaan kuulutuksia...",
"direct": "Suorat",
"downloading": "Ladataan",
"hops": "{count} hyppyä",
@@ -1583,6 +1586,15 @@
"failed_send_ingest": "Lukupyyntö epäonnistui",
"address_copied": "LXMF-kohteesi on kopioitu leikepöydälle",
"map_link_share_title": "Jaettu karttanäkymä",
+ "relay_link_hub_title": "Jaettu Relay Chat -hub",
+ "relay_link_room_title": "Jaettu Relay Chat -huone",
+ "relay_link_join": "Liity Relay Chatiin",
+ "relay_link_copy_uri": "Kopioi relay-linkki",
+ "relay_link_copied": "Relay-linkki kopioitu",
+ "relay_link_opened": "Avattu Relay Chatissa",
+ "relay_link_invalid": "Virheellinen relay-linkki",
+ "relay_link_failed": "Relay-linkkiä ei voitu avata",
+ "relay_link_disabled": "Relay Chat on poistettu käytöstä asetuksissa",
"map_link_ping_title": "Karttasijainti",
"map_link_open": "Avaa kartalla",
"map_link_copy_uri": "Kopioi MeshChatX-linkki",
@@ -2636,6 +2648,7 @@
"hub_name": "Keskuksen nimi (valinnainen)",
"hub_name_placeholder": "Suosikkikeskukseni",
"dest_name": "Kohdeaspekti (valinnainen)",
+ "advanced": "Lisäasetukset",
"no_hubs": "Ei vielä määritettyjä keskuksia. Lisää yksi aloittaaksesi.",
"no_rooms": "Et ole liittynyt yhteenkään huoneeseen. Liity huoneeseen aloittaaksesi keskustelun.",
"available_rooms": "Käytettävissä olevat huoneet",
@@ -2688,6 +2701,11 @@
"no_hosted_hubs": "Ei vielä isännöityjä keskuksia. Luo yksi aloittaaksesi viestien välityksen.",
"copy_hash": "Kopioi kohdehash",
"hash_copied": "Kohdehash kopioitu",
+ "share_hub": "Jaa hub-linkki",
+ "share_copied": "Relay-jakolinkki kopioitu",
+ "host_join_as_client": "Liity tähän hubiin asiakkaana",
+ "host_leave_as_client": "Poistu tästä hubista asiakkaana",
+ "host_joined_as_client": "Liityttiin isännöityyn hubiin asiakkaana",
"host_start": "Käynnistä",
"host_status_running": "Käynnissä",
"host_status_stopped": "Pysäytetty",
@@ -2756,6 +2774,9 @@
"ctx_connect_hub": "Yhdistä",
"ctx_disconnect_hub": "Katkaise yhteys",
"ctx_hub_settings": "Keskuksen asetukset",
+ "ctx_copy_hub_address": "Kopioi hub-osoite",
+ "ctx_share_hub": "Jaa hub",
+ "ctx_share_room": "Jaa huone",
"ctx_remove_hub": "Poista keskus",
"ctx_leave_room": "Poistu huoneesta",
"ctx_reply_quote": "Vastaa lainauksella",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index da180daa..08b06585 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -143,6 +143,8 @@
"nomad_tabs_enabled_description": "Ouvre plusieurs pages NomadNet dans des onglets sur les grands écrans.",
"rrc_enabled": "Chat relais",
"rrc_enabled_description": "Active le chat relais, la découverte de hubs et l'hébergement. Désactivé, le chat relais est masqué et les annonces de hubs sont ignorées.",
+ "rrc_unread_badges_enabled": "Badges non lus Relay Chat",
+ "rrc_unread_badges_enabled_description": "Afficher les compteurs de messages non lus sur les hubs et salles. Les mentions du badge de navigation ne sont pas affectées.",
"reset_appearance_defaults": "Réinitialiser l'apparence aux valeurs par défaut",
"light_theme": "Thème lumineux",
"dark_theme": "Thème sombre",
@@ -1448,6 +1450,7 @@
"no_peers_discovered": "Aucun pair découvert",
"waiting_for_announce": "Attendre que quelqu'un annonce !",
"no_search_results_peers": "Votre recherche ne correspond à aucun Peers !",
+ "searching_announces": "Recherche des annonces...",
"direct": "Direct",
"downloading": "Téléchargement",
"hops": "Houblon{count}",
@@ -1583,6 +1586,15 @@
"failed_send_ingest": "Échec de l'envoi de la demande d'ingestion",
"address_copied": "Votre adresse LXMF copiée dans le presse-papiers",
"map_link_share_title": "Vue cartographique partagée",
+ "relay_link_hub_title": "Hub Relay Chat partagé",
+ "relay_link_room_title": "Salle Relay Chat partagée",
+ "relay_link_join": "Rejoindre dans Relay Chat",
+ "relay_link_copy_uri": "Copier le lien relay",
+ "relay_link_copied": "Lien relay copié",
+ "relay_link_opened": "Ouvert dans Relay Chat",
+ "relay_link_invalid": "Lien relay invalide",
+ "relay_link_failed": "Impossible d’ouvrir le lien relay",
+ "relay_link_disabled": "Relay Chat est désactivé dans les paramètres",
"map_link_ping_title": "Ping cartographique",
"map_link_open": "Ouvrir sur la carte",
"map_link_copy_uri": "Copier le lien meshchatx",
@@ -3290,6 +3302,7 @@
"hub_name": "Nom du hub (optionnel)",
"hub_name_placeholder": "Mon hub prefere",
"dest_name": "Aspect de destination (optionnel)",
+ "advanced": "Avancé",
"no_hubs": "Aucun hub configure. Ajoutez-en un pour commencer.",
"no_rooms": "Aucun salon rejoint. Rejoignez un salon pour discuter.",
"available_rooms": "Salons disponibles",
@@ -3342,6 +3355,11 @@
"no_hosted_hubs": "Aucun hub hébergé pour l'instant. Créez-en un pour relayer le chat.",
"copy_hash": "Copier le hash de destination",
"hash_copied": "Hash de destination copié",
+ "share_hub": "Partager le lien du hub",
+ "share_copied": "Lien de partage relay copié",
+ "host_join_as_client": "Rejoindre ce hub en tant que client",
+ "host_leave_as_client": "Quitter ce hub en tant que client",
+ "host_joined_as_client": "Hub hébergé rejoint en tant que client",
"host_start": "Démarrer",
"host_status_running": "En cours",
"host_status_stopped": "Arrêté",
@@ -3435,6 +3453,9 @@
"ctx_connect_hub": "Se connecter",
"ctx_disconnect_hub": "Se déconnecter",
"ctx_hub_settings": "Paramètres du hub",
+ "ctx_copy_hub_address": "Copier l’adresse du hub",
+ "ctx_share_hub": "Partager le hub",
+ "ctx_share_room": "Partager la salle",
"ctx_remove_hub": "Supprimer le hub",
"ctx_leave_room": "Quitter le salon",
"ctx_reply_quote": "Répondre avec citation",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index a2a3511f..eb72d8b4 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -143,6 +143,8 @@
"nomad_tabs_enabled_description": "Apre più pagine NomadNet in schede sugli schermi grandi.",
"rrc_enabled": "Chat relay",
"rrc_enabled_description": "Abilita chat relay, scoperta hub e hosting. Se disattivato, la chat relay è nascosta e gli annunci hub vengono ignorati.",
+ "rrc_unread_badges_enabled": "Badge non letti Relay Chat",
+ "rrc_unread_badges_enabled_description": "Mostra i contatori dei messaggi non letti su hub e stanze. Le menzioni nel badge di navigazione non sono influenzate.",
"reset_appearance_defaults": "Ripristina aspetto predefinito",
"light_theme": "Tema Chiaro",
"dark_theme": "Tema Scuro",
@@ -1500,6 +1502,7 @@
"no_peers_discovered": "Nessun Peer Scoperto",
"waiting_for_announce": "In attesa che qualcuno annunci!",
"no_search_results_peers": "La tua ricerca non corrisponde ad alcun Peer!",
+ "searching_announces": "Ricerca annunci in corso...",
"direct": "Diretto",
"downloading": "Scaricamento",
"hops": "{count} salti",
@@ -1639,6 +1642,15 @@
"failed_send_ingest": "Impossibile inviare la richiesta di ingest",
"address_copied": "Il tuo indirizzo LXMF è stato copiato negli appunti",
"map_link_share_title": "Vista mappa condivisa",
+ "relay_link_hub_title": "Hub Relay Chat condiviso",
+ "relay_link_room_title": "Stanza Relay Chat condivisa",
+ "relay_link_join": "Unisciti in Relay Chat",
+ "relay_link_copy_uri": "Copia link relay",
+ "relay_link_copied": "Link relay copiato",
+ "relay_link_opened": "Aperto in Relay Chat",
+ "relay_link_invalid": "Link relay non valido",
+ "relay_link_failed": "Impossibile aprire il link relay",
+ "relay_link_disabled": "Relay Chat è disabilitato nelle impostazioni",
"map_link_ping_title": "Ping mappa",
"map_link_open": "Apri sulla mappa",
"map_link_copy_uri": "Copia link meshchatx",
@@ -3290,6 +3302,7 @@
"hub_name": "Nome dell'hub (opzionale)",
"hub_name_placeholder": "Il mio hub preferito",
"dest_name": "Aspetto di destinazione (opzionale)",
+ "advanced": "Avanzate",
"no_hubs": "Nessun hub configurato. Aggiungine uno per iniziare.",
"no_rooms": "Nessuna stanza. Entra in una stanza per chattare.",
"available_rooms": "Stanze disponibili",
@@ -3342,6 +3355,11 @@
"no_hosted_hubs": "Nessun hub ospitato. Creane uno per iniziare a inoltrare la chat.",
"copy_hash": "Copia hash di destinazione",
"hash_copied": "Hash di destinazione copiato",
+ "share_hub": "Condividi link hub",
+ "share_copied": "Link di condivisione relay copiato",
+ "host_join_as_client": "Unisciti a questo hub come client",
+ "host_leave_as_client": "Lascia questo hub come client",
+ "host_joined_as_client": "Hub ospitato raggiunto come client",
"host_start": "Avvia",
"host_status_running": "In esecuzione",
"host_status_stopped": "Arrestato",
@@ -3435,6 +3453,9 @@
"ctx_connect_hub": "Connetti",
"ctx_disconnect_hub": "Disconnetti",
"ctx_hub_settings": "Impostazioni hub",
+ "ctx_copy_hub_address": "Copia indirizzo hub",
+ "ctx_share_hub": "Condividi hub",
+ "ctx_share_room": "Condividi stanza",
"ctx_remove_hub": "Rimuovi hub",
"ctx_leave_room": "Esci dalla stanza",
"ctx_reply_quote": "Rispondi con citazione",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index a6566b76..983df3ca 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -143,6 +143,8 @@
"nomad_tabs_enabled_description": "Opent meerdere NomadNet-pagina's in tabbladen op grotere schermen.",
"rrc_enabled": "Relaychat",
"rrc_enabled_description": "Schakelt relaychat, hub-ontdekking en hosting in. Uitgeschakeld: relaychat is verborgen en hub-aankondigingen worden genegeerd.",
+ "rrc_unread_badges_enabled": "Relay Chat ongelezen badges",
+ "rrc_unread_badges_enabled_description": "Toon ongelezen berichtentellers op hubs en kamers. Vermeldingen in de navigatiebadge blijven onaangetast.",
"reset_appearance_defaults": "Het uiterlijk terugzetten naar standaardinstellingen",
"light_theme": "Lichtthema",
"dark_theme": "Donker thema",
@@ -1448,6 +1450,7 @@
"no_peers_discovered": "Geen peers ontdekt",
"waiting_for_announce": "Wachten tot iemand het bekend maakt!",
"no_search_results_peers": "Uw zoekopdracht kwam niet overeen met die van Peers!",
+ "searching_announces": "Aankondigingen zoeken...",
"direct": "Rechtstreeks",
"downloading": "Downloaden",
"hops": "{count}hop",
@@ -1583,6 +1586,15 @@
"failed_send_ingest": "Kon het invoerverzoek niet versturen",
"address_copied": "Uw LXMF-adres gekopieerd naar klembord",
"map_link_share_title": "Gedeeld kaartoverzicht",
+ "relay_link_hub_title": "Gedeelde Relay Chat-hub",
+ "relay_link_room_title": "Gedeelde Relay Chat-kamer",
+ "relay_link_join": "Deelnemen in Relay Chat",
+ "relay_link_copy_uri": "Relay-link kopiëren",
+ "relay_link_copied": "Relay-link gekopieerd",
+ "relay_link_opened": "Geopend in Relay Chat",
+ "relay_link_invalid": "Ongeldige relay-link",
+ "relay_link_failed": "Kon relay-link niet openen",
+ "relay_link_disabled": "Relay Chat is uitgeschakeld in de instellingen",
"map_link_ping_title": "Kaart-ping",
"map_link_open": "Openen op kaart",
"map_link_copy_uri": "MeshChatX-link kopiëren",
@@ -3290,6 +3302,7 @@
"hub_name": "Hubnaam (optioneel)",
"hub_name_placeholder": "Mijn favoriete hub",
"dest_name": "Bestemmingsaspect (optioneel)",
+ "advanced": "Geavanceerd",
"no_hubs": "Nog geen hubs geconfigureerd. Voeg er een toe om te beginnen.",
"no_rooms": "Geen kamers. Word lid van een kamer om te chatten.",
"available_rooms": "Beschikbare kamers",
@@ -3342,6 +3355,11 @@
"no_hosted_hubs": "Nog geen gehoste hubs. Maak er een om chat door te geven.",
"copy_hash": "Bestemmingshash kopiëren",
"hash_copied": "Bestemmingshash gekopieerd",
+ "share_hub": "Hub-link delen",
+ "share_copied": "Relay-deellink gekopieerd",
+ "host_join_as_client": "Deelnemen aan deze hub als client",
+ "host_leave_as_client": "Deze hub als client verlaten",
+ "host_joined_as_client": "Deelgenomen aan gehoste hub als client",
"host_start": "Starten",
"host_status_running": "Actief",
"host_status_stopped": "Gestopt",
@@ -3435,6 +3453,9 @@
"ctx_connect_hub": "Verbinden",
"ctx_disconnect_hub": "Verbreken",
"ctx_hub_settings": "Hub-instellingen",
+ "ctx_copy_hub_address": "Hub-adres kopiëren",
+ "ctx_share_hub": "Hub delen",
+ "ctx_share_room": "Kamer delen",
"ctx_remove_hub": "Hub verwijderen",
"ctx_leave_room": "Kamer verlaten",
"ctx_reply_quote": "Antwoorden met citaat",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 5a85f079..78871198 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -114,6 +114,8 @@
"nomad_tabs_enabled_description": "Открывает несколько страниц NomadNet во вкладках на больших экранах.",
"rrc_enabled": "Релейный чат",
"rrc_enabled_description": "Включает релейный чат, обнаружение хабов и хостинг. Если выключено, релейный чат скрыт, а объявления хабов игнорируются.",
+ "rrc_unread_badges_enabled": "Значки непрочитанных Relay Chat",
+ "rrc_unread_badges_enabled_description": "Показывать счётчики непрочитанных сообщений на хабах и в комнатах. Упоминания в значке навигации не затрагиваются.",
"reset_appearance_defaults": "Сбросить оформление к умолчаниям",
"light_theme": "Светлая тема",
"dark_theme": "Темная тема",
@@ -1500,6 +1502,7 @@
"no_peers_discovered": "Собеседники не обнаружены",
"waiting_for_announce": "Ожидание анонсов!",
"no_search_results_peers": "По вашему запросу собеседников не найдено!",
+ "searching_announces": "Поиск анонсов...",
"direct": "Прямая связь",
"downloading": "Загрузка",
"hops": "{count} прыжков",
@@ -1639,6 +1642,15 @@
"failed_send_ingest": "Не удалось отправить запрос на обработку",
"address_copied": "Ваш LXMF-адрес скопирован в буфер обмена",
"map_link_share_title": "Общий вид карты",
+ "relay_link_hub_title": "Общий хаб Relay Chat",
+ "relay_link_room_title": "Общая комната Relay Chat",
+ "relay_link_join": "Присоединиться в Relay Chat",
+ "relay_link_copy_uri": "Копировать ссылку relay",
+ "relay_link_copied": "Ссылка relay скопирована",
+ "relay_link_opened": "Открыто в Relay Chat",
+ "relay_link_invalid": "Недействительная ссылка relay",
+ "relay_link_failed": "Не удалось открыть ссылку relay",
+ "relay_link_disabled": "Relay Chat отключён в настройках",
"map_link_ping_title": "Метка на карте",
"map_link_open": "Открыть на карте",
"map_link_copy_uri": "Копировать ссылку meshchatx",
@@ -3290,6 +3302,7 @@
"hub_name": "Имя хаба (необязательно)",
"hub_name_placeholder": "Мой любимый хаб",
"dest_name": "Аспект назначения (необязательно)",
+ "advanced": "Дополнительно",
"no_hubs": "Хабы ещё не настроены. Добавьте один, чтобы начать.",
"no_rooms": "Нет комнат. Присоединитесь к комнате, чтобы общаться.",
"available_rooms": "Доступные комнаты",
@@ -3342,6 +3355,11 @@
"no_hosted_hubs": "Пока нет размещённых хабов. Создайте один, чтобы ретранслировать чат.",
"copy_hash": "Копировать хеш назначения",
"hash_copied": "Хеш назначения скопирован",
+ "share_hub": "Поделиться ссылкой на хаб",
+ "share_copied": "Ссылка для обмена relay скопирована",
+ "host_join_as_client": "Подключиться к этому хабу как клиент",
+ "host_leave_as_client": "Покинуть этот хаб как клиент",
+ "host_joined_as_client": "Подключено к размещённому хабу как клиент",
"host_start": "Запустить",
"host_status_running": "Работает",
"host_status_stopped": "Остановлен",
@@ -3435,6 +3453,9 @@
"ctx_connect_hub": "Подключиться",
"ctx_disconnect_hub": "Отключиться",
"ctx_hub_settings": "Настройки хаба",
+ "ctx_copy_hub_address": "Копировать адрес хаба",
+ "ctx_share_hub": "Поделиться хабом",
+ "ctx_share_room": "Поделиться комнатой",
"ctx_remove_hub": "Удалить хаб",
"ctx_leave_room": "Покинуть комнату",
"ctx_reply_quote": "Ответить с цитатой",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 7e2a7db0..aab59f40 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -143,6 +143,8 @@
"nomad_tabs_enabled_description": "在较大屏幕上以标签页打开多个 NomadNet 页面。",
"rrc_enabled": "中继聊天",
"rrc_enabled_description": "启用中继聊天、中心发现与托管。关闭后,中继聊天将隐藏,并忽略中心公告。",
+ "rrc_unread_badges_enabled": "中继聊天未读标记",
+ "rrc_unread_badges_enabled_description": "在枢纽和房间上显示未读消息计数。导航栏提及标记不受影响。",
"reset_appearance_defaults": "重置外观为默认",
"light_theme": "浅色主题",
"dark_theme": "深色主题",
@@ -1448,6 +1450,7 @@
"no_peers_discovered": "未发现端点",
"waiting_for_announce": "等待某人的广播!",
"no_search_results_peers": "您的搜索与任何端点都不匹配!",
+ "searching_announces": "正在搜索广播...",
"direct": "直接",
"downloading": "下载中",
"hops": "{count} 跳",
@@ -1583,6 +1586,15 @@
"failed_send_ingest": "发送摄取请求失败",
"address_copied": "您的 LXMF 地址已复制到剪贴板",
"map_link_share_title": "共享地图视图",
+ "relay_link_hub_title": "共享的中继聊天枢纽",
+ "relay_link_room_title": "共享的中继聊天房间",
+ "relay_link_join": "在中继聊天中加入",
+ "relay_link_copy_uri": "复制中继链接",
+ "relay_link_copied": "中继链接已复制",
+ "relay_link_opened": "已在中继聊天中打开",
+ "relay_link_invalid": "无效的中继链接",
+ "relay_link_failed": "无法打开中继链接",
+ "relay_link_disabled": "设置中已禁用中继聊天",
"map_link_ping_title": "地图 Ping",
"map_link_open": "在地图上打开",
"map_link_copy_uri": "复制 MeshChatX 链接",
@@ -3290,6 +3302,7 @@
"hub_name": "中心名称(可选)",
"hub_name_placeholder": "我最喜欢的中心",
"dest_name": "目标方面(可选)",
+ "advanced": "高级",
"no_hubs": "尚未配置中心。添加一个以开始。",
"no_rooms": "未加入任何房间。加入房间开始聊天。",
"available_rooms": "可用房间",
@@ -3342,6 +3355,11 @@
"no_hosted_hubs": "暂无托管的中枢。创建一个以开始转发聊天。",
"copy_hash": "复制目标哈希",
"hash_copied": "已复制目标哈希",
+ "share_hub": "分享枢纽链接",
+ "share_copied": "中继分享链接已复制",
+ "host_join_as_client": "以客户端加入此枢纽",
+ "host_leave_as_client": "以客户端离开此枢纽",
+ "host_joined_as_client": "已以客户端加入托管枢纽",
"host_start": "启动",
"host_status_running": "运行中",
"host_status_stopped": "已停止",
@@ -3435,6 +3453,9 @@
"ctx_connect_hub": "连接",
"ctx_disconnect_hub": "断开连接",
"ctx_hub_settings": "中心设置",
+ "ctx_copy_hub_address": "复制枢纽地址",
+ "ctx_share_hub": "分享枢纽",
+ "ctx_share_room": "分享房间",
"ctx_remove_hub": "移除中心",
"ctx_leave_room": "离开房间",
"ctx_reply_quote": "引用回复",
diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index 2418af2a..968c7ecb 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -121,6 +121,82 @@ describe("MessagesPage.vue", () => {
vi.useRealTimers();
});
+ it("tracks isSearchingAnnounces while the debounce and request are pending, clearing it once resolved", async () => {
+ vi.useFakeTimers();
+ axiosMock.isCancel = vi.fn(() => false);
+
+ let resolveAnnounces;
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config")
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } });
+ if (url === "/api/v1/announces") {
+ return new Promise((resolve) => {
+ resolveAnnounces = resolve;
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isSearchingAnnounces).toBe(false);
+
+ wrapper.vm.onPeersSearchChanged("peerq");
+ expect(wrapper.vm.isSearchingAnnounces).toBe(true);
+
+ await vi.advanceTimersByTimeAsync(500);
+ expect(wrapper.vm.isSearchingAnnounces).toBe(true);
+
+ resolveAnnounces({ data: { announces: [], total_count: 0 } });
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isSearchingAnnounces).toBe(false);
+
+ vi.useRealTimers();
+ });
+
+ it("does not clear isSearchingAnnounces for a stale request superseded by a newer search", async () => {
+ vi.useFakeTimers();
+ axiosMock.isCancel = vi.fn((e) => e?.isCancelled === true);
+
+ const pending = [];
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config")
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } });
+ if (url === "/api/v1/announces") {
+ return new Promise((resolve, reject) => {
+ pending.push({ resolve, reject });
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.onPeersSearchChanged("first");
+ await vi.advanceTimersByTimeAsync(500);
+ expect(pending.length).toBe(1);
+
+ wrapper.vm.onPeersSearchChanged("second");
+ await vi.advanceTimersByTimeAsync(500);
+ expect(pending.length).toBe(2);
+ pending[0].reject({ isCancelled: true });
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.isSearchingAnnounces).toBe(true);
+
+ pending[1].resolve({ data: { announces: [], total_count: 0 } });
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isSearchingAnnounces).toBe(false);
+
+ vi.useRealTimers();
+ });
+
it("does not prematurely clear isLoadingConversations when a superseded request aborts", async () => {
vi.useFakeTimers();
axiosMock.isCancel = vi.fn((e) => e?.isCancelled === true);
diff --git a/tests/frontend/MessagesSidebar.test.js b/tests/frontend/MessagesSidebar.test.js
index 56aabd40..28308024 100644
--- a/tests/frontend/MessagesSidebar.test.js
+++ b/tests/frontend/MessagesSidebar.test.js
@@ -22,9 +22,6 @@ vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
import Utils from "../../meshchatx/src/frontend/js/Utils";
-const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
-const LxmfUserIcon = { template: '<div class="lxmf-icon"></div>' };
-
function defaultProps(overrides = {}) {
return {
peers: {},
@@ -36,6 +33,7 @@ function defaultProps(overrides = {}) {
isLoadingMore: false,
hasMoreConversations: false,
isLoadingMoreAnnounces: false,
+ isSearchingAnnounces: false,
hasMoreAnnounces: false,
totalPeersCount: 0,
...overrides,
@@ -46,9 +44,15 @@ function mountSidebar(props = {}, options = {}) {
return mount(MessagesSidebar, {
props: defaultProps(props),
global: {
- components: { MaterialDesignIcon, LxmfUserIcon },
mocks: { $t: (key) => key },
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '<div class="mdi" :data-icon-name="iconName"></div>',
+ props: ["iconName"],
+ },
+ LxmfUserIcon: { template: '<div class="lxmf-icon"></div>' },
+ },
},
...options,
});
@@ -236,4 +240,55 @@ describe("MessagesSidebar UI", () => {
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
});
+
+ async function openAnnouncesTab(wrapper) {
+ const tabs = wrapper.findAll("div.flex.w-full.cursor-pointer.border-b-2");
+ await tabs[1].trigger("click");
+ await wrapper.vm.$nextTick();
+ }
+
+ it("shows a spinner next to the search input while an announce search is in progress", async () => {
+ const wrapper = mountSidebar({ isSearchingAnnounces: true });
+ await openAnnouncesTab(wrapper);
+
+ const spinner = wrapper.find('[data-icon-name="loading"]');
+ expect(spinner.exists()).toBe(true);
+ });
+
+ it("does not show a search spinner when isSearchingAnnounces is false", async () => {
+ const wrapper = mountSidebar({ isSearchingAnnounces: false });
+ await openAnnouncesTab(wrapper);
+
+ const spinner = wrapper.find('[data-icon-name="loading"]');
+ expect(spinner.exists()).toBe(false);
+ });
+
+ it("shows a searching placeholder instead of the empty state while search results are still loading", async () => {
+ const wrapper = mountSidebar({
+ peers: {},
+ totalPeersCount: 0,
+ peersSearchTerm: "nonexistent",
+ isSearchingAnnounces: true,
+ });
+ await openAnnouncesTab(wrapper);
+
+ expect(wrapper.text()).toContain("messages.searching_announces");
+ expect(wrapper.text()).not.toContain("messages.no_peers_discovered");
+ expect(wrapper.text()).not.toContain("messages.no_search_results_peers");
+ });
+
+ it("shows the no-results-for-search message once a search finishes with no matches", async () => {
+ const wrapper = mountSidebar({
+ peers: {},
+ totalPeersCount: 0,
+ peersSearchTerm: "nonexistent",
+ isSearchingAnnounces: false,
+ });
+ await openAnnouncesTab(wrapper);
+
+ expect(wrapper.text()).toContain("messages.no_search_results");
+ expect(wrapper.text()).toContain("messages.no_search_results_peers");
+ expect(wrapper.text()).not.toContain("messages.searching_announces");
+ expect(wrapper.text()).not.toContain("messages.no_peers_discovered");
+ });
});
diff --git a/tests/frontend/RelayChatPage.test.js b/tests/frontend/RelayChatPage.test.js
index e1dacf6a..8d2adcd9 100644
--- a/tests/frontend/RelayChatPage.test.js
+++ b/tests/frontend/RelayChatPage.test.js
@@ -571,6 +571,33 @@ describe("RelayChatPage.vue", () => {
);
});
+ it("joins a hosted hub as a client from the host card action", async () => {
+ axiosMock.post.mockResolvedValue({
+ data: { hub: makeHub({ hub_hash: "aabbccddeeff00112233445566778899" }) },
+ });
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
+ const hosted = wrapper.vm.serverHubs[0];
+ await wrapper.vm.joinHostedAsClient(hosted);
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/rrc/hubs",
+ expect.objectContaining({
+ hub_hash: hosted.dest_hash,
+ connect: true,
+ })
+ );
+ expect(wrapper.vm.view).toBe("chat");
+ expect(wrapper.vm.selectedHubHash).toBe(hosted.dest_hash);
+ });
+
+ it("keeps destination aspect collapsed in the add-hub dialog by default", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+ wrapper.vm.openAddHub();
+ expect(wrapper.vm.showAddHub).toBe(true);
+ expect(wrapper.vm.addHubAdvancedOpen).toBe(false);
+ });
+
it("loads hosted hub members when opening the moderation page", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
diff --git a/tests/frontend/relayLinkUtils.test.js b/tests/frontend/relayLinkUtils.test.js
new file mode 100644
index 00000000..efd4b309
--- /dev/null
+++ b/tests/frontend/relayLinkUtils.test.js
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, vi } from "vitest";
+import {
+ applyRelayShareLink,
+ buildMeshchatRelayUri,
+ buildRelayShareMessage,
+ findRelayUriInContent,
+ parseMeshchatRelayUri,
+} from "@/js/relayLinkUtils.js";
+
+const HUB = "00112233445566778899aabbccddeeff";
+
+describe("relayLinkUtils", () => {
+ it("builds and parses meshchatx relay URIs", () => {
+ const uri = buildMeshchatRelayUri({
+ hub: HUB,
+ room: "lobby",
+ name: "Test Hub",
+ aspect: "custom.hub",
+ });
+ expect(uri.startsWith("meshchatx://relay?")).toBe(true);
+ const p = parseMeshchatRelayUri(uri);
+ expect(p).not.toBeNull();
+ expect(p.hub).toBe(HUB);
+ expect(p.room).toBe("lobby");
+ expect(p.name).toBe("Test Hub");
+ expect(p.aspect).toBe("custom.hub");
+ });
+
+ it("accepts meshchat:// alias and defaults aspect", () => {
+ const p = parseMeshchatRelayUri(`meshchat://relay?hub=${HUB}`);
+ expect(p).not.toBeNull();
+ expect(p.aspect).toBe("rrc.hub");
+ expect(p.room).toBe("");
+ });
+
+ it("rejects invalid hub hashes", () => {
+ expect(parseMeshchatRelayUri("meshchatx://relay?hub=short")).toBeNull();
+ expect(buildMeshchatRelayUri({ hub: "nope" })).toBeNull();
+ });
+
+ it("finds first relay URI in text", () => {
+ const text = `See meshchatx://relay?hub=${HUB}&room=general end`;
+ expect(findRelayUriInContent(text)).toBe(`meshchatx://relay?hub=${HUB}&room=general`);
+ });
+
+ it("builds share message text", () => {
+ expect(buildRelayShareMessage({ hub: HUB })).toBe(`MeshChatX relay: meshchatx://relay?hub=${HUB}`);
+ expect(buildRelayShareMessage({ hub: HUB, room: "lobby" })).toBe(
+ `MeshChatX relay room: meshchatx://relay?hub=${HUB}&room=lobby`
+ );
+ });
+
+ it("applyRelayShareLink adds hub and joins room", async () => {
+ const api = {
+ get: vi.fn().mockResolvedValue({ data: { hubs: [] } }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ const result = await applyRelayShareLink({ hub: HUB, room: "lobby", name: "N", aspect: "rrc.hub" }, { api });
+ expect(api.post).toHaveBeenCalledWith("/api/v1/rrc/hubs", {
+ hub_hash: HUB,
+ name: "N",
+ dest_name: "rrc.hub",
+ connect: true,
+ });
+ expect(api.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB}/rooms`, { room: "lobby" });
+ expect(result).toEqual({ hub_hash: HUB, room: "lobby" });
+ });
+
+ it("applyRelayShareLink reconnects existing disconnected hub", async () => {
+ const api = {
+ get: vi.fn().mockResolvedValue({
+ data: { hubs: [{ hub_hash: HUB, connected: false }] },
+ }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ await applyRelayShareLink({ hub: HUB, room: "", name: "", aspect: "rrc.hub" }, { api });
+ expect(api.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB}/connect`);
+ expect(api.post).not.toHaveBeenCalledWith("/api/v1/rrc/hubs", expect.anything());
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────